2661 PR 2/3 Warship Port Healing, Docking Capacity, and Waiting Behavior#3499
2661 PR 2/3 Warship Port Healing, Docking Capacity, and Waiting Behavior#3499Zixer1 wants to merge 9 commits intoopenfrontio:mainfrom
Conversation
WalkthroughWarships now detect low health, retreat to friendly ports, dock, and receive passive and port-based healing; port capacity, switching, and retreat cancellation are enforced. Config getters, game/unit APIs, rendering, execution logic, and tests were updated to support this lifecycle. Changes
Sequence Diagram(s)sequenceDiagram
participant Warship
participant Execution
participant Port
participant Game
Warship->>Execution: tick()
Execution->>Warship: checkHealth()
alt below retreat threshold
Execution->>Game: setRetreating(true)
Execution->>Port: findNearestFriendlyPort(warship)
Port-->>Execution: portCandidate
Execution->>Warship: navigateTo(port.tile)
loop approaching port
Warship->>Execution: position update / tick
Execution->>Port: checkDockRadius & capacity
alt within radius and port has capacity
Execution->>Warship: setDocked(true)
Execution->>Port: allocateHealingPool()
Port-->>Warship: applyPortHealing(+passive)
else port full or not in range
Execution->>Warship: continue navigating / remain retreating
end
end
alt fully healed or no port
Execution->>Game: setRetreating(false)
Execution->>Warship: setDocked(false)
end
else
Execution->>Warship: normal targeting (prioritize TransportShip) & combat
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Poem
🚥 Pre-merge checks | ✅ 3✅ Passed checks (3 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/core/game/UnitImpl.ts (1)
223-230:⚠️ Potential issue | 🟠 MajorAvoid sending no-op health updates.
Line 229 now emits a unit update even when
withinInt(...)clamps back to the same health.WarshipExecution.healWarship()calls this every tick near ports, so full-health ships will start sending useless updates and redraws. Only emit when health really changed, or when the unit is crossing to0HP.💡 Suggested change
modifyHealth(delta: number, attacker?: Player): void { - this._health = withinInt( + const nextHealth = withinInt( this._health + toInt(delta), 0n, toInt(this.info().maxHealth ?? 1), ); - this.mg.addUpdate(this.toUpdate()); + const changed = nextHealth !== this._health; + this._health = nextHealth; + if (changed || this._health === 0n) { + this.mg.addUpdate(this.toUpdate()); + } if (this._health === 0n) { this.delete(true, attacker); } }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/core/game/UnitImpl.ts` around lines 223 - 230, The modifyHealth method currently always calls this.mg.addUpdate(this.toUpdate()) even when the clamped health equals the previous value; change modifyHealth (in UnitImpl.modifyHealth) to capture the previous health (e.g., prev = this._health), compute the new clamped value as now, assign only after comparison, and only call this.mg.addUpdate(this.toUpdate()) when now !== prev OR when prev !== 0n && now === 0n (i.e., crossing to death) so healWarship ticks on full-health ships won't emit no-op updates; keep the existing use of withinInt, toInt and this.info().maxHealth when computing the clamped value.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/core/execution/WarshipExecution.ts`:
- Around line 149-151: The split-heal math currently produces fractional values
(healingPool / dockedShips) but UnitImpl.modifyHealth stores health as an
integer causing silent truncation; update the healing path so fractional HP are
preserved by making UnitImpl.modifyHealth accept and store a floating-point
number (change its parameter/type and the internal health field from integer to
number) and ensure any serialization/display layers (e.g., health getters or
network payloads) round for presentation only; update references to modifyHealth
in WarshipExecution and any other callers to pass the numeric (float) value
unchanged and add/adjust tests to cover fractional heals.
- Around line 133-152: The healing logic in WarshipExecution uses a hard-coded
docking radius (5) and counts all friendly warships, which differs from
isPortFullOfHealing() that uses warshipPortHealingRadius() and considers
retreating ships; update WarshipExecution (and the other occurrences around the
noted ranges) to compute the same docked-ship set used by isPortFullOfHealing():
read docking radius from mg.config().warshipPortHealingRadius(), filter
owner.units(UnitType.Warship) by the same retreating/retreat-state predicate
used in isPortFullOfHealing(), and use that single set both to compute
dockedShips (capacity) and to divide the healingPool (activeHealing); ensure you
replace the hard-coded 5 and any differing membership tests so healing and
port-full checks operate on the identical ship set.
In `@tests/Warship.test.ts`:
- Around line 340-370: The test never fills the port slot because only warship1
attempts to retreat; create a second warship (e.g., warship2 via
player1.buildUnit(UnitType.Warship, ...)), start its WarshipExecution (new
WarshipExecution(warship2) and game.addExecution(exec2)), damage it
(warship2.modifyHealth(...)) and advance ticks until exec2.isDocked() is true so
the port slot is occupied, then damage warship1 and run the existing loop to
verify warship1 waits and exec1.isDocked() becomes true only after the slot
frees; reference WarshipExecution, exec1, exec2, warship1.modifyHealth and
warship2.modifyHealth to locate changes.
- Around line 203-224: The test currently asserts warship.targetUnit() after a
single tick which is unstable; instead, make the assertion on a stable
first-engagement effect: configure warshipShellAttackRate to ensure an attack
occurs (as in the setup), run ticks until the warship's attack resolves (use
game.executeNextTick() in a loop or until a combat event), and then assert that
the TransportShip's hit points or alive state changed (or that the TransportShip
took damage) rather than checking warship.targetUnit(); reference the
WarshipExecution instance, the warship variable, and the TransportShip unit
created to locate where to change the assertion.
---
Outside diff comments:
In `@src/core/game/UnitImpl.ts`:
- Around line 223-230: The modifyHealth method currently always calls
this.mg.addUpdate(this.toUpdate()) even when the clamped health equals the
previous value; change modifyHealth (in UnitImpl.modifyHealth) to capture the
previous health (e.g., prev = this._health), compute the new clamped value as
now, assign only after comparison, and only call
this.mg.addUpdate(this.toUpdate()) when now !== prev OR when prev !== 0n && now
=== 0n (i.e., crossing to death) so healWarship ticks on full-health ships won't
emit no-op updates; keep the existing use of withinInt, toInt and
this.info().maxHealth when computing the clamped value.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 91fa0df0-a18f-4396-9e47-4a408327d7ca
📒 Files selected for processing (8)
src/client/graphics/layers/UnitLayer.tssrc/core/configuration/Config.tssrc/core/configuration/DefaultConfig.tssrc/core/execution/WarshipExecution.tssrc/core/game/Game.tssrc/core/game/GameView.tssrc/core/game/UnitImpl.tstests/Warship.test.ts
8243d9e to
2960a06
Compare
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
tests/Warship.test.ts (1)
361-411: Extend this regression to cover the slot-opening step.This currently proves only the first waiting state. If
exec2never retries afterexec1heals and undocks, the test still passes, so the “wait until a slot opens” rule is not locked in yet.🧪 One way to tighten the assertion
expect(exec1.isDocked()).toBe(true); expect(exec2.isDocked()).toBe(false); expect(warship2DistanceToPort).toBeLessThanOrEqual(25); expect(warship2.retreating()).toBe(true); + + for (let i = 0; i < 120; i++) { + game.executeNextTick(); + if (!exec1.isDocked() && exec2.isDocked()) { + break; + } + } + + expect(exec2.isDocked()).toBe(true);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Warship.test.ts` around lines 361 - 411, The current test only asserts the initial waiting state but doesn't verify that exec2 actually retries and takes the port slot after exec1 undocks; update the test (the "Warship waits at port when capacity is full" case) to continue advancing ticks after detecting exec1.isDocked() becomes false (or after exec1 heals/undocks) and then wait until exec2.isDocked() becomes true (and exec2.retreating() is false), asserting that exec2 eventually docks within a reasonable tick limit; use the existing WarshipExecution instances (exec1, exec2) and units (warship1, warship2) and the methods isDocked() and retreating() to implement this additional check.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@src/core/execution/WarshipExecution.ts`:
- Around line 252-254: The current distance checks use a single global
warshipPortHealingRadius() so all ports share the same docking radius; change
the logic to compute a level-aware docking radius using the port's level (e.g.,
call a new config accessor or apply a scaling based on port.level()) and then
compute dockingRadiusSq from that value before comparing with distToPort; update
both occurrences where dockingRadius/dockingRadiusSq is used (the block
computing dockingRadius/dockingRadiusSq and the similar block later) so
port.level() influences docking range rather than only capacity.
- Around line 86-107: The current flow sets the target with
warship.setTargetUnit(this.findTargetUnit()) then calls patrol() which can
change position and invalidate range for subsequent shoots; to fix, either call
this.shootTarget() (or perform range checks) before calling this.patrol(), or
recompute the target after movement by calling
this.warship.setTargetUnit(this.findTargetUnit()) (and/or verify
this.warship.targetUnit() range inside shootTarget()) so shootTarget() always
uses a fresh, in-range target; update the logic around findTargetUnit, patrol,
warship.setTargetUnit, and shootTarget accordingly.
- Around line 66-72: The early return when this.docked is true must revalidate
the docked port before exiting: inside the WarshipExecution branch that checks
this.docked (around isFullyHealed()/cancelRepairRetreat()), call
refreshRetreatPortTile() (and/or applyActiveDockedHealing()) to confirm the
friendly port still exists/controls the tile; if the refresh indicates the port
is gone or no longer friendly, clear this.docked and call cancelRepairRetreat()
so execution can proceed normally; otherwise, keep the existing behavior (leave
docked true and return). Ensure you reference the WarshipExecution methods
refreshRetreatPortTile(), applyActiveDockedHealing(), isFullyHealed(), and
cancelRepairRetreat() when making the check and state change.
- Around line 194-205: The current fallback loop in WarshipExecution that picks
the closest port even when component filtering found no reachable ports must be
removed so unreachable ports aren't returned; update the helper (the method
containing the shown loop) to return undefined when no ports remain after
component-based reachability filtering instead of computing bestTile by
distance, and mirror this same change in findNearestAvailablePortTile() and the
other analogous helper later in the file so handleRepairRetreat() receives
undefined for unreachable ports and can cancel the retreat cleanly (do not
reintroduce a global nearest-port fallback).
---
Nitpick comments:
In `@tests/Warship.test.ts`:
- Around line 361-411: The current test only asserts the initial waiting state
but doesn't verify that exec2 actually retries and takes the port slot after
exec1 undocks; update the test (the "Warship waits at port when capacity is
full" case) to continue advancing ticks after detecting exec1.isDocked() becomes
false (or after exec1 heals/undocks) and then wait until exec2.isDocked()
becomes true (and exec2.retreating() is false), asserting that exec2 eventually
docks within a reasonable tick limit; use the existing WarshipExecution
instances (exec1, exec2) and units (warship1, warship2) and the methods
isDocked() and retreating() to implement this additional check.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 69996902-7364-4549-ae57-b2994fba4523
📒 Files selected for processing (8)
src/client/graphics/layers/UnitLayer.tssrc/core/configuration/Config.tssrc/core/configuration/DefaultConfig.tssrc/core/execution/WarshipExecution.tssrc/core/game/Game.tssrc/core/game/GameView.tssrc/core/game/UnitImpl.tstests/Warship.test.ts
✅ Files skipped from review due to trivial changes (2)
- src/core/game/GameView.ts
- src/core/configuration/Config.ts
🚧 Files skipped from review as they are similar to previous changes (3)
- src/core/game/Game.ts
- src/client/graphics/layers/UnitLayer.ts
- src/core/configuration/DefaultConfig.ts
There was a problem hiding this comment.
🧹 Nitpick comments (2)
tests/Warship.test.ts (1)
470-475: Internal method mocking is fragile but acceptable for unit-level testing.Casting to access
findTargetUnitandpathfinderworks but ties the test to internal implementation details. If these private members are renamed or refactored, the test will fail at runtime rather than compile time. Consider extracting an interface or making these protected if such unit-level testing is needed long-term.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@tests/Warship.test.ts` around lines 470 - 475, The test is reaching into private internals by casting to executionInternals to access findTargetUnit and pathfinder (and PathStatus/transport), which is fragile; refactor by exposing a test-friendly interface or making those members protected so tests can depend on a stable contract: define an interface (e.g., IExecutionTestHelpers) with findTargetUnit and pathfinder.next signatures, update the class under test to implement or expose that interface (or provide a protected accessor) and change the test to cast to that interface instead of an ad-hoc unknown cast, ensuring compile-time safety if findTargetUnit or pathfinder are renamed/refactored.src/core/execution/WarshipExecution.ts (1)
399-430: Port-finding methods share similar iteration logic - consider consolidating.
findNearestAvailablePort()andfindNearestAvailablePortTile()both iterate over ports with water component filtering. The main difference is whether to excludethis.warshipfrom capacity checks. Consider a single helper method with anexcludeSelfparameter to reduce duplication.♻️ Possible consolidation approach
+ private findNearestAvailablePortTileImpl(excludeSelf: boolean): TileRef | undefined { + const ports = this.warship.owner().units(UnitType.Port); + if (ports.length === 0) { + return undefined; + } + + const warshipTile = this.warship.tile(); + const warshipComponent = this.mg.getWaterComponent(warshipTile); + const excludeShip = excludeSelf ? this.warship : undefined; + + let bestTile: TileRef | undefined = undefined; + let bestDistance = Infinity; + for (const port of ports) { + if (this.isPortFullOfHealing(port, excludeShip)) { + continue; + } + const portTile = port.tile(); + if ( + warshipComponent !== null && + !this.mg.hasWaterComponent(portTile, warshipComponent) + ) { + continue; + } + const distance = this.mg.euclideanDistSquared(warshipTile, portTile); + if (distance < bestDistance) { + bestDistance = distance; + bestTile = portTile; + } + } + return bestTile; + } + + private findNearestAvailablePort(): TileRef | undefined { + return this.findNearestAvailablePortTileImpl(false); + } + + private findNearestAvailablePortTile(): TileRef | undefined { + return this.findNearestAvailablePortTileImpl(true); + }Also applies to: 483-516
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@src/core/execution/WarshipExecution.ts` around lines 399 - 430, Consolidate duplicated port-iteration logic in findNearestAvailablePort() and findNearestAvailablePortTile() by extracting a single helper (e.g., findNearestPortTileWithOptions or findNearestPortTile) that accepts an option/flag like excludeSelf:boolean (or a predicate) and reuses the same water-component filtering and distance calculation; update both original methods to call the new helper with excludeSelf true/false as appropriate, ensure the helper uses this.isPortFullOfHealing(port) conditioned on excludeSelf when checking capacity, and keep return type TileRef | undefined unchanged so callers and tests continue to work.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@src/core/execution/WarshipExecution.ts`:
- Around line 399-430: Consolidate duplicated port-iteration logic in
findNearestAvailablePort() and findNearestAvailablePortTile() by extracting a
single helper (e.g., findNearestPortTileWithOptions or findNearestPortTile) that
accepts an option/flag like excludeSelf:boolean (or a predicate) and reuses the
same water-component filtering and distance calculation; update both original
methods to call the new helper with excludeSelf true/false as appropriate,
ensure the helper uses this.isPortFullOfHealing(port) conditioned on excludeSelf
when checking capacity, and keep return type TileRef | undefined unchanged so
callers and tests continue to work.
In `@tests/Warship.test.ts`:
- Around line 470-475: The test is reaching into private internals by casting to
executionInternals to access findTargetUnit and pathfinder (and
PathStatus/transport), which is fragile; refactor by exposing a test-friendly
interface or making those members protected so tests can depend on a stable
contract: define an interface (e.g., IExecutionTestHelpers) with findTargetUnit
and pathfinder.next signatures, update the class under test to implement or
expose that interface (or provide a protected accessor) and change the test to
cast to that interface instead of an ad-hoc unknown cast, ensuring compile-time
safety if findTargetUnit or pathfinder are renamed/refactored.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 982856a2-04a6-4a80-8be4-e191037247d4
📒 Files selected for processing (2)
src/core/execution/WarshipExecution.tstests/Warship.test.ts
Part of #2661 (split into 3 PRs so they are not too large..)
Description:
Part 2/3 of #2661.
This PR adds port-based healing and docking behavior:
For the active healing, it works like
ActiveHeal = (PortLevel * 5) / DockedShipsAtThatPortEx:
1 ship at level 1 port -> +5 HP/tick
1 ship at level 2 port → +10 HP/tick
2 ships at level 3 port → +7.5 HP/tick each
Includes regression tests covering healing math and docking/waiting behavior.
Please complete the following:
Please put your Discord username so you can be contacted if a bug or regression is found:
zixer._